fix: validate the dispute initiator before refunding the escrow (#805) - #825
fix: validate the dispute initiator before refunding the escrow (#805)#825grunch wants to merge 3 commits into
Conversation
`admin_cancel_action` called `cancel_hold_invoice` — an irreversible refund of the seller's escrow — before resolving the dispute initiator, and that resolution can reject the request with `DisputeEventError` when neither `seller_dispute` nor `buyer_dispute` is set (or both are). On that path the seller was already refunded while the order stayed in `Dispute` and the status transition to `CanceledByAdmin` never ran, leaving the Lightning side and the DB permanently disagreeing with no way back. Move the initiator resolution above the refund so every check that can reject the call runs before the escrow is touched. The valid path is unchanged. Regression test: an order with a hold-invoice hash and no initiator flag now fails with `DisputeEventError` and is left in `Dispute`. Before the fix it returned `LnNodeError` — proof the refund RPC had already been dispatched. Closes #805
Walkthrough
ChangesAdmin dispute action safety
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Admin
participant Handler
participant Order
participant Lightning
participant Notifications
Admin->>Handler: submit dispute action
Handler->>Order: validate initiator and required keys
alt invalid state
Handler-->>Admin: DisputeEventError
else valid state
Handler->>Lightning: cancel or settle hold invoice
Handler->>Order: update order state
Handler->>Notifications: send admin, seller, and buyer DMs
Notifications-->>Handler: log delivery failures
end
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Strict review completed on the current head.
The change fixes the funds-safety ordering bug without altering the valid cancellation path: malformed/ambiguous initiator flags are now rejected before the irreversible hold-invoice cancellation, while the existing valid-hash test still proves that a legitimate request reaches the LND cancellation seam. The new regression test is a real before/after discriminator because the old ordering returns LnNodeError against the dead-LND harness before it can produce DisputeEventError.
I also checked authorization, dispute-status and bond-resolution ordering, the seller/buyer initiator mapping used in the kind-38386 dispute event, retry behavior in the malformed-state path, and the tracked CAS/atomicity concerns outside this PR's scope. No blocking regression found.
CI is green for build, tests, fmt, clippy, and the Rust 1.94.0 MSRV build.
|
@coderabbitai review |
✅ Action performedReview finished.
|
Follow-up to the strict review of #825. The PR moved the dispute-initiator resolution above cancel_hold_invoice, but three gaps of the same class (#805) remained: - admin_cancel resolved the counterparty pubkeys only after the refund, the dispute row and the order status had been written. A missing or unparseable pubkey rejected the call once the money had already moved, past any solver retry (the Dispute guard rejects a second cancel) and before the bond resolution, stranding every Locked bond — only range maker bonds have a reconciler. The resolution now runs next to the initiator check. - The DM fan-out returned on the first failure, skipping the same bond resolution on a mere relay hiccup. Delivery is now best effort with an error log per recipient, mirroring notify_bond_slashed. - admin_settle still resolved the initiator after settle_seller_hold_invoice, which is equally irreversible. Hoisted above the settle. Both ambiguous-flag arms now log order id and both flags: DisputeEventError alone gave the operator nothing to diagnose with. Tests: an unparseable-pubkey rejection and an ambiguous-flag settle are each pinned before their escrow move (both fail against the previous ordering), plus the both-flags-set arm and an assertion that a failed DM no longer aborts admin_cancel before the bond resolution. Stale doc comments on the existing tests corrected.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/admin_cancel.rs (1)
794-807: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftMake the DM-failure regression actually exercise the failure path.
With no global Nostr client,
send_dmreturnsOk(()); this test never enters the newErrbranch. It also creates no locked bond, so it does not prove bond resolution ran. Inject/configure a notifier that deterministically fails and assert the seeded bond reaches its expected resolved state.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/admin_cancel.rs` around lines 794 - 807, Update the regression test around admin_cancel_action to configure a deterministic failing notifier/client so send_dm enters the Err path, and seed a locked bond before invoking the handler. Afterward, retain the successful handler assertion and verify the seeded bond reaches its expected resolved state, proving bond resolution continues despite DM failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/app/admin_cancel.rs`:
- Around line 794-807: Update the regression test around admin_cancel_action to
configure a deterministic failing notifier/client so send_dm enters the Err
path, and seed a locked bond before invoking the handler. Afterward, retain the
successful handler assertion and verify the seeded bond reaches its expected
resolved state, proving bond resolution continues despite DM failure.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 03d53ed9-ed53-4e1d-bfe2-57f40e55e731
📒 Files selected for processing (2)
src/app/admin_cancel.rssrc/app/admin_settle.rs
Closes #805.
The bug
admin_cancel_actionrefunded the seller's escrow before it finished validating the request:When neither initiator flag is set (or both are), the handler returns
DisputeEventError— butcancel_hold_invoicehas already returned the funds to the seller. The order stays inDispute, the transition toCanceledByAdminnever runs, and the dispute row is never moved toSellerRefunded. The Lightning side and the DB disagree permanently, and the refund cannot be undone.The fix
Move the initiator resolution above the refund, so every check that can reject the call runs before the escrow is touched. This is the same ordering discipline already applied to the status guards and the bond-resolution validation just above it (see the existing Phase 2 comment). The valid path — a legitimately flagged initiator — is unchanged.
Test
New regression test
dispute_without_initiator_flag_errors_before_refunding: an order with a hold-invoicehashand no initiator flag must fail withDisputeEventErrorand be left inDispute.The test is a genuine before/after discriminator thanks to the existing
dead_lnd()harness (a realLndConnectoragainst a dead endpoint, so any RPC fails fast):Err(MostroInternalErr(LnNodeError("code=Unknown message=client error (Connect)")))— proof the refund RPC had already been dispatched.DisputeEventErrorwithout ever reaching LND.dispute_with_hash_reaches_ln_cancel_seamstill passes, confirming the legitimate refund path is untouched.Verification
cargo test— 1016 passed, 0 failedcargo clippy --all-targets -- -D warnings— cleancargo fmt --all— cleanNote
admin_settle.rshas related ordering/CAS concerns tracked separately in #809 and #810; this PR deliberately stays scoped to #805.Manual testing
The unit tests pin the ordering at the handler seam; this is how to reproduce
the bug and verify the fix end to end against a real LND. The single
observable that matters throughout: on a request that is going to be
rejected, the hold invoice must still be
ACCEPTEDafterwards.0. Environment
cd docker && make docker-up(see
docker/README.md), orcargo runagainst Polar directly.read-writepermission (admin-add-solver), plusmostro-clifor the maker/taker/solver messages.[rpc] enabled = trueinsettings.toml, soCancelOrdercan be driven over gRPC and the error isreturned to the caller instead of only logged.
git checkout fix/admin-cancel-validate-before-refund && cargo runInspection helpers used below (
<ID>= order id,<HASH>=orders.hash):1. Common setup — a disputed order with a live escrow
lncli lookupinvoice <HASH>showsACCEPTED; buyer sends the payout invoice; buyer sendsfiat-sent.dispute.admin-take-dispute.Checkpoint before every test below:
orders.status = dispute, exactly oneof
seller_dispute/buyer_disputeis1,orders.hashis set,disputes.status = in-progress, invoice stateACCEPTED, any bond rowslocked.2. Test A — the legitimate path is unchanged (regression guard)
Send
admin-cancelfor the order as the assigned solver.Must all hold to approve:
lookupinvoice <HASH>→CANCELED(escrow returned to the seller).orders.status→canceled-by-admin.disputes.status→seller-refunded.s = seller-refundedandinitiator = <the side that actually opened the dispute>— this tag mustmatch the flag set in step 1.3, not an arbitrary side.
admin-canceledDM.locked→released(orslashedwhen aBondResolutionwas attached).3. Test B — ambiguous initiator must not touch the escrow (the #805 bug)
The flags are only corruptible out of band, so force the state directly on a
freshly disputed order (repeat §1):
sqlite3 mostro.db "update orders set seller_dispute=0, buyer_dispute=0 where id='<ID>';"Send
admin-cancel. Must all hold:lookupinvoice <HASH>→ stillACCEPTED. (Onmainthis returnsCANCELED: the seller was refunded on a request that was then rejected.)orders.status→ stilldispute;disputes.statusstillin-progress; bond rows stilllocked.admin_cancel: ambiguous dispute initiator flags; refusing before the escrow is touchedwith
order_id,seller_dispute=false,buyer_dispute=false.Admin cancel failed: … DisputeEventError;over Nostr the daemon logs the warning and no
admin-canceledDM isemitted to anyone.
(
update orders set seller_dispute=1 …) and re-sendadmin-cancel→ itnow completes exactly as in Test A. Nothing was stranded by the rejection.
4. Test C — both flags set is equally ambiguous
Same as Test B, but
set seller_dispute=1, buyer_dispute=1. Same expectedresult: rejected, invoice
ACCEPTED, orderdispute, same log line withseller_dispute=true, buyer_dispute=true. (Pre-fix this arm also refundedfirst.)
5. Test D — missing counterparty pubkey is rejected before the refund
On a fresh disputed order:
sqlite3 mostro.db "update orders set buyer_pubkey=NULL where id='<ID>';"Send
admin-cancel. Must all hold:InvalidPubkey;lookupinvoice <HASH>→ stillACCEPTED;orders.statusstilldispute;disputes.statusunchanged; bond rows still
locked.(On
mainthis rejection happened after the refund, after the dispute rowwas moved to
seller-refundedand after the order becamecanceled-by-admin— past the point where a retry is possible, since theDisputestatus guard rejects the second attempt, leaving the bondsLockedwith no reconciler.)6. Test E — a failing DM must not abort the bond resolution
Needs
[rpc] enabled = trueso the command does not travel over the relaythat is about to be stopped.
lockedbond rows.docker compose stop nostr-relay).CancelOrderover gRPC.Must all hold:
admin_cancel: failed to notify the admin/seller/buyer of the cancellation: …(one line per undelivered recipient) — and executioncontinues past them.
orders.status = canceled-by-admin, invoiceCANCELED, and critically thebond rows are no longer
locked.(On
mainthe firstsend_dmerror returned early, so the bonds stayedLockedwith no retry path.)7. Test F —
admin-settlegot the same treatmentFresh disputed order per §1 (buyer payout invoice present), then corrupt the
flags as in Test B and send
admin-settle.Must all hold:
DisputeEventError; log lineadmin_settle: ambiguous dispute initiator flags; refusing before the escrow is settled.lookupinvoice <HASH>→ stillACCEPTED, i.e. notSETTLED— theseller's escrow was not captured.
orders.statusstilldispute.admin-settle→ the invoice goes toSETTLED, the order leavesdispute(settled-hold-invoice, then thepayout to the buyer),
disputes.status = settled, and theadmin-settledDMs go out.
8. Approval criteria
Approve only if every criterion below is observed:
main, with the correctinitiatortag.lookupinvoicestill reportsACCEPTED— no LND state change at all.disputeand the dispute row keeps its previous status.locked.admin-canceledDM sent to any party.Reject if any rejected request shows a
CANCELED/SETTLEDinvoice, an orderthat left
dispute, a bond stuck inlockedafter a successful cancel, or aretry that is refused after the underlying row was repaired.
Optional before/after: run tests B and D against
mainfirst — theinvoice ends
CANCELEDwhile the order stays indispute, which is theexact split-brain #805 describes.
Summary by CodeRabbit
Disputefor safe retries.#805) to verify early failure ordering and unchangedDisputestatus; added a settlement pre-check test for missing initiator flags.